雖然開頭說會以網頁動畫做為最後一個子題,但是寫到後面發現預先規劃好的內容,比起動畫,更偏向滑鼠、滾輪事件這類互動效果。到最後幾天突然鬼轉,實作範例存量不夠了有沒有 😂
今天想來分享拖曳功能。以前的需求都是列表內拖曳選項來改變排序,所以直接使用 vue-draggable-plus 套件處理。前陣子的專案遇到不一樣的拖曳操作,是要將下方選項拖曳到上方地圖的圖釘,選項需要被拖曳出列表外,還要在靠近圖釘放開後將選項填寫進去。剛好利用這次機會,研究看看如何寫出拖曳效果。
今天的範例都是用 Vue 寫成,那麼我們開始吧!
一開始問 AI 與 google 搜尋時,得知 HTML 有內建拖曳機制,透過替元素加上 draggable="true",再監聽 dragstart、dragover、drop 就能使用。但是有下列限制:
setDragImage() 換成自訂圖片,但它是一張靜態的影像,無法在拖曳過程中隨狀態變化。反觀 Pointer Events 則是把滑鼠、觸控、手寫筆統一成同一組事件,寫一次就能支援不同裝置;而樣式客製化就交由 CSS 設定。
不管多複雜的拖曳,基本都是同一個流程:
pointerdown:記下起點,開始拖曳。pointermove:更新位置。pointerup:結束,決定要不要放置。我們先來宣告對應的事件 function,並綁在要拖曳的選項上。這邊沒有把 pointermove 和 pointerup 直接綁上 DOM,而是在 pointerdown 發生時才往 window 掛上去,結束時再移除。是因為當使用者拖得快時,pointer 常常會跑出選項的範圍,如果監聽掛在選項上,事件就斷掉。
<template>
<div
v-for="option in options"
:key="option.id"
class="option"
@pointerdown="onDown($event, option)"
>
{{ option.name }}
</div>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
const drag = ref(null) // 紀錄拖曳狀態用,沒在拖曳時是 null
const onMove = (e) => {
// 後面步驟再補
}
const endDrag = () => {
drag.value = null
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', endDrag)
window.removeEventListener('pointercancel', endDrag)
}
const onDown = (e, option) => {
drag.value = { optionId: option.id, startX: e.clientX, startY: e.clientY, moved: false }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', endDrag)
window.addEventListener('pointercancel', endDrag)
}
onBeforeUnmount(endDrag) // 拖曳中離開頁面時,把監聽清掉
</script>
另外,監聽掛在 window 上,元件被卸載時 Vue 不會自動清掉。如果使用者正在拖曳時就切換頁面,監聽還在,卻沒有對應的畫面,所以要在 onBeforeUnmount (元件即將被卸載)裡呼叫一次 endDrag。
💡
pointercancel也要一起監聽、一起移除,否則被中斷時監聽會殘留。這個事件在步驟 3 會再提到。
基礎打好了,接下來照事件發生的順序,一步一步把細節補上。每個階段都有各自會踩的坑,就在遇到的那一步順便處理。
拖曳開始之前,先在 CSS 裡設定好各個元素的互動相關屬性。
/* 1. 可拖曳的選項 */
.option {
cursor: grab;
user-select: none;
touch-action: none; /* 這個元素上的觸控,瀏覽器不要自己處理手勢 */
}
/* 2. 跟著 pointer 跑的 ghost */
.ghost {
position: fixed;
pointer-events: none; /* 不然 ghost 會擋住後面的命中判定 */
}
/* 3. 拖曳中鎖住整個頁面的游標與文字選取(拖曳中要加上的 class) */
body.is-dragging,
body.is-dragging * {
cursor: grabbing !important;
user-select: none !important;
}
最容易漏掉 touch-action: none。手指在螢幕上拖,瀏覽器的預設行為是「捲動頁面」,一旦瀏覽器決定接手,就會對這個 pointer 發出 pointercancel,拖曳就會中斷。所以需要這行告訴瀏覽器「這裡的觸控手勢我自己處理」。
當使用者按下選項的瞬間,依序處理:過濾掉不該觸發的情況 → 記錄起點和偏移量 → 掛上後續事件的監聽。
const onDown = (e, option) => {
// 是滑鼠的話,只有左鍵會觸發
if (e.pointerType === 'mouse' && e.button !== 0) return
if (drag.value) endDrag()
// 記錄 pointer 在選項內的偏移量,以及顯示上需要的資訊
const rect = e.currentTarget.getBoundingClientRect()
drag.value = {
optionId: option.id,
name: option.name,
x: e.clientX, // pointer 目前位置,之後 pointermove 會更新
y: e.clientY,
startX: e.clientX,
startY: e.clientY,
ox: e.clientX - rect.left, // pointer 距離選項左邊多遠
oy: e.clientY - rect.top, // pointer 距離選項上邊多遠
hoverId: null, // 目前命中的圖釘
moved: false,
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', endDrag)
window.addEventListener('pointercancel', endDrag)
}
ox、oy 偏移量的作用: 使用者抓的是選項的中間,如果直接把 ghost 的 left、top 設成 pointer 座標,ghost 的左上角會跳到 pointer 位置,畫面會「彈」一下。先記下 pointer 距離選項左上角多遠,之後每次都扣掉,ghost 就會維持在使用者抓的位置。
座標系要一致。 clientX / clientY 和 getBoundingClientRect() 都是相對於視窗左上角,不受捲動影響,所以可以直接相減。ghost 定位用 position: fixed,也是相對於視窗定位,這三個剛好對得上,不用另外換算距離。
使用者按下後開始拖動,pointermove 方法中依序處理:設立門檻判斷是否開始 → 顯示 ghost → 更新位置 → 找出目標圖釘。
<template>
<!-- 拖曳中才出現,位置由 ghostStyle 決定 -->
<div v-if="drag?.moved" class="ghost" :style="ghostStyle">{{ drag.name }}</div>
</template>
<script setup>
import { computed } from 'vue'
// ghost 位置 = pointer 座標 - 按下時的偏移量
const ghostStyle = computed(() => ({
left: `${drag.value.x - drag.value.ox}px`,
top: `${drag.value.y - drag.value.oy}px`,
}))
const onMove = (e) => {
if (!drag.value) return
// 1. 門檻:位移超過 3px 才算真的開始拖
if (!drag.value.moved) {
const dx = e.clientX - drag.value.startX
const dy = e.clientY - drag.value.startY
if (dx * dx + dy * dy < 9) return // 3 * 3
drag.value.moved = true // ghost 因為 v-if 條件成立而出現
document.body.classList.add('is-dragging')
}
// 2. 更新 pointer 位置,ghost 會跟著動
drag.value.x = e.clientX
drag.value.y = e.clientY
// 3. 找出最近的圖釘,在畫面上提示使用者將寫入哪個圖釘。nearestPinId() 的內容請看下一段。
drag.value.hoverId = nearestPinId(e.clientX, e.clientY)
}
</script>
門檻 是為了避免誤觸。使用者只是想點一下選項,手指或滑鼠卻有些微抖動,就被判定成拖曳。moved 變成 true 之後,ghost 才會因為 v-if 出現(前端框架是由資料狀態驅動畫面),is-dragging 也在這時才加上。
<template>
<!-- 圖釘們 -->
<div
v-for="pin in pins"
:key="pin.id"
:ref="(el) => setPinEl(pin.id, el)"
class="pin"
:class="{ hover: drag?.moved && drag.hoverId === pin.id }"
>
{{ pin.id }}
</div>
</template>
<script setup>
const pinEls = new Map() // { 圖釘 id: DOM 元素 },不需要響應式所以沒用 ref
// 取得圖釘們的 DOM
const setPinEl = (id, el) => {
if (el) pinEls.set(id, el)
else pinEls.delete(id)
}
const nearestPinId = (x, y) => {
let bestId = null
let bestDist = 56 // 超過這個距離就不算命中
for (const [id, el] of pinEls) {
const r = el.getBoundingClientRect()
const d = Math.hypot(x - (r.left + r.width / 2), y - (r.top + r.height / 2))
if (d <= bestDist) {
bestDist = d
bestId = id
}
}
return bestId // 沒命中就是 null
}
</script>
命中判定 用距離來計算:對每個圖釘取中心點,算和 pointer 的距離,56px 以內取最近的一個。
這裡刻意不用「pointer 在圖釘的方框內」來判斷。是因為圖釘尺寸偏小,相較之下手指太粗,要求精確對準對使用者而言不太友善。於是給每個圖釘一個比視覺範圍更大的「吸附圈」,讓操作手感變得更好。
💡 如果只需要『剛好碰到才算』,可以改用
document.elementFromPoint(x, y)直接問瀏覽器「這個座標底下是什麼元素」。不過要注意 ghost 必須要有pointer-events: none屬性。
當使用者放開時,依序處理:有效拖曳才更新資料 → 重置資料與移除監聽
const endDrag = (e) => {
if (!drag.value) return
const { optionId, hoverId, moved } = drag.value
// 是 pointerup + 剛剛正在拖曳 + 目標圖釘的 id = 有效
if (e?.type === 'pointerup' && moved && hoverId != null) {
placeOption(optionId, hoverId)
}
// 設回 null、body 移除 .is-dragging、清除監聽
drag.value = null
document.body.classList.remove('is-dragging')
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', endDrag)
window.removeEventListener('pointercancel', endDrag)
}
pointerup 是使用者主動放開,而 pointercancel 是被系統中斷(例如來電、瀏覽器接手手勢)。這個範例只接受使用者主動放開。
另外,一個選項只能放在一個圖釘。所以放到新圖釘前,placeOption() 要先把這個選項在其他圖釘上的紀錄清掉:
const placements = ref({}) // 已經填寫好資料的對照表 { 圖釘 id: 選項 id }
const placeOption = (optionId, pinId) => {
const next = { ...placements.value }
for (const [oldPinId, oldOptionId] of Object.entries(next)) {
if (oldOptionId === optionId) delete next[oldPinId]
}
next[pinId] = optionId
placements.value = next
}
Day25 Clear!倒數剩下 5 天!
明天繼續網頁互動的主題,想要來看看 vue-draggable-plus 套件幫我做了什麼、FLIP 又是什麼呢?
明天見!